// Copyright 2025 Canonical Ltd.
// Licensed under the AGPLv3, see LICENCE file for details.

package service

import (
	"context"
	stderrs "errors"
	"testing"
	"time"

	"github.com/juju/clock"
	"github.com/juju/tc"
	"go.uber.org/mock/gomock"

	corestatus "github.com/juju/juju/core/status"
	"github.com/juju/juju/domain/operation/internal"
	loggertesting "github.com/juju/juju/internal/logger/testing"
)

type migrationSuite struct {
	st        *MockState
	objGetter *MockModelObjectStoreGetter
}

func TestMigrationSuite(t *testing.T) {
	// Run the suite using tc to keep consistency across the package tests.
	tc.Run(t, &migrationSuite{})
}

func (s *migrationSuite) setupMocks(c *tc.C) *gomock.Controller {
	ctrl := gomock.NewController(c)
	s.st = NewMockState(ctrl)
	s.objGetter = NewMockModelObjectStoreGetter(ctrl)
	return ctrl
}

func (s *migrationSuite) service(c *tc.C) *Service {
	return NewService(s.st, clock.WallClock, loggertesting.WrapCheckLog(c), s.objGetter, nil)
}

// TestImportOperationsGeneratesAndPreservesUUIDs validates that UUIDs are
// generated when missing and preserved when provided.
func (s *migrationSuite) TestImportOperationsGeneratesAndPreservesUUIDs(c *tc.C) {
	// Arrange
	defer s.setupMocks(c).Finish()

	presetUUID := "preset-uuid-1234"
	in := internal.ImportOperationsArgs{
		{
			ID:        "op-1",
			Summary:   "sum1",
			Enqueued:  time.Now().Add(-2 * time.Hour),
			Started:   time.Now().Add(-90 * time.Minute),
			Completed: time.Time{},
			Status:    corestatus.Pending,
			Fail:      "",
			Tasks:     nil,
			UUID:      "", // should be generated by service
		},
		{
			ID:        "op-2",
			Summary:   "sum2",
			Enqueued:  time.Now().Add(-1 * time.Hour),
			Started:   time.Now().Add(-30 * time.Minute),
			Completed: time.Now().Add(-10 * time.Minute),
			Status:    corestatus.Completed,
			Fail:      "",
			Tasks:     nil,
			UUID:      presetUUID, // should be preserved
		},
	}

	// Assert arguments sent to state
	s.st.EXPECT().InsertMigratingOperations(gomock.Any(), gomock.Any()).DoAndReturn(
		func(_ context.Context, got internal.ImportOperationsArgs) error {
			c.Assert(len(got), tc.Equals, len(in))

			// The first arg should have a generated UUID and preserve other fields.
			c.Check(got[0].ID, tc.Equals, in[0].ID)
			c.Check(got[0].Summary, tc.Equals, in[0].Summary)
			c.Check(got[0].Enqueued.Equal(in[0].Enqueued), tc.IsTrue)
			c.Check(got[0].Started.Equal(in[0].Started), tc.IsTrue)
			c.Check(got[0].Completed.Equal(in[0].Completed), tc.IsTrue)
			c.Check(got[0].Status, tc.Equals, in[0].Status)
			c.Check(got[0].Fail, tc.Equals, in[0].Fail)
			c.Assert(got[0].UUID == "", tc.IsFalse)

			// The second arg should preserve UUID and other fields.
			c.Check(got[1].UUID, tc.Equals, presetUUID)
			c.Check(got[1].ID, tc.Equals, in[1].ID)
			c.Check(got[1].Summary, tc.Equals, in[1].Summary)
			c.Check(got[1].Enqueued.Equal(in[1].Enqueued), tc.IsTrue)
			c.Check(got[1].Started.Equal(in[1].Started), tc.IsTrue)
			c.Check(got[1].Completed.Equal(in[1].Completed), tc.IsTrue)
			c.Check(got[1].Status, tc.Equals, in[1].Status)
			c.Check(got[1].Fail, tc.Equals, in[1].Fail)
			return nil
		},
	)

	// Act
	err := s.service(c).InsertMigratingOperations(c.Context(), in)

	// Assert
	c.Assert(err, tc.IsNil)
}

// TestImportOperationsErrorPropagation validates that errors from the state are
// captured and returned by the service.
func (s *migrationSuite) TestImportOperationsErrorPropagation(c *tc.C) {
	// Arrange
	defer s.setupMocks(c).Finish()

	in := internal.ImportOperationsArgs{{ID: "op-1"}}
	sentinel := stderrs.New("boom")
	s.st.EXPECT().InsertMigratingOperations(gomock.Any(), gomock.Any()).Return(sentinel)

	// Act
	err := s.service(c).InsertMigratingOperations(c.Context(), in)

	// Assert
	c.Assert(err, tc.ErrorIs, sentinel)
}

// TestDeleteImportedOperationsSuccess validates success paths for
// DeleteImportedOperations.
func (s *migrationSuite) TestDeleteImportedOperationsSuccessNoStorePath(c *tc.C) {
	// Arrange
	defer s.setupMocks(c).Finish()

	// Success path
	s.st.EXPECT().DeleteImportedOperations(gomock.Any()).Return(nil, nil)

	// Act
	err := s.service(c).DeleteImportedOperations(c.Context())

	// Assert
	c.Assert(err, tc.IsNil)
}

// TestDeleteImportedOperationsError validates both error paths for
// DeleteImportedOperations.
func (s *migrationSuite) TestDeleteImportedOperationsError(c *tc.C) {
	// Arrange
	defer s.setupMocks(c).Finish()

	// Arrange error path
	sentinel := stderrs.New("fail-delete")
	s.st.EXPECT().DeleteImportedOperations(gomock.Any()).Return(nil, sentinel)

	// Act
	err := s.service(c).DeleteImportedOperations(c.Context())

	// Assert
	c.Assert(err, tc.ErrorIs, sentinel)
}

// TestImportOperationsStoresTaskResultsAndSetsStoreUUIDs ensures that for tasks
// with non-empty Output the results are stored and StoreUUIDs are recorded; and
// for tasks with empty Output nothing is stored.
func (s *migrationSuite) TestImportOperationsStoresTaskResultsAndSetsStoreUUIDs(c *tc.C) {
	ctrl := s.setupMocks(c)
	defer ctrl.Finish()

	store := NewMockObjectStore(ctrl)
	// Expect two puts for two tasks with outputs.
	s.objGetter.EXPECT().GetObjectStore(gomock.Any()).Return(store, nil).Times(2)
	store.EXPECT().Put(gomock.Any(), "t-uuid-1", gomock.Any(), gomock.Any()).Return("store-uuid-1", nil)
	store.EXPECT().Put(gomock.Any(), "t-uuid-2", gomock.Any(), gomock.Any()).Return("store-uuid-2", nil)

	in := internal.ImportOperationsArgs{
		{
			ID:      "op-1",
			Summary: "sum",
			Tasks: []internal.ImportTaskArg{
				{ID: "t1", UUID: "t-uuid-1", Output: map[string]any{"k": "v"}},
				{ID: "t2", UUID: "t-uuid-2", Output: map[string]any{"x": 1}},
				{ID: "t3", Output: map[string]any{}}, // empty output; UUID should still be generated
			},
			UUID: "", // op uuid should be generated
		},
	}

	s.st.EXPECT().InsertMigratingOperations(gomock.Any(), gomock.Any()).DoAndReturn(
		func(_ context.Context, got internal.ImportOperationsArgs) error {
			c.Assert(len(got), tc.Equals, 1)
			c.Assert(len(got[0].Tasks), tc.Equals, 3)
			// Operation UUID should be set.
			c.Assert(got[0].UUID == "", tc.IsFalse)
			// Task UUIDs should be preserved/generated accordingly.
			c.Check(got[0].Tasks[0].UUID, tc.Equals, "t-uuid-1")
			c.Check(got[0].Tasks[1].UUID, tc.Equals, "t-uuid-2")
			c.Assert(got[0].Tasks[2].UUID == "", tc.IsFalse)
			// StorePath is always the taskUUID, when results are stored, and empty otherwise.
			c.Check(got[0].Tasks[0].StorePath, tc.Equals, "t-uuid-1")
			c.Check(got[0].Tasks[1].StorePath, tc.Equals, "t-uuid-2")
			c.Check(got[0].Tasks[2].StorePath, tc.Equals, "")
			return nil
		},
	)

	err := s.service(c).InsertMigratingOperations(c.Context(), in)
	c.Assert(err, tc.IsNil)
}

// TestImportOperationsErrorWhileSerializingResultsPreventsStateCall ensures that
// if results cannot be serialized, we error before calling state and before
// touching the object store.
func (s *migrationSuite) TestImportOperationsErrorWhileSerializingResultsPreventsStateCall(c *tc.C) {
	ctrl := s.setupMocks(c)
	defer ctrl.Finish()

	badFn := func() {}
	in := internal.ImportOperationsArgs{
		{
			ID:    "op-err",
			Tasks: []internal.ImportTaskArg{{ID: "t1", UUID: "t-uuid-bad", Output: map[string]any{"bad": badFn}}},
		},
	}

	err := s.service(c).InsertMigratingOperations(c.Context(), in)
	c.Assert(err, tc.ErrorMatches, ".*putting task result.*")
}

// TestImportOperationsRollsBackStoredResultsOnStateError ensures we rollback
// stored results when the state import fails.
func (s *migrationSuite) TestImportOperationsRollsBackStoredResultsOnStateError(c *tc.C) {
	ctrl := s.setupMocks(c)
	defer ctrl.Finish()

	store := NewMockObjectStore(ctrl)
	s.objGetter.EXPECT().GetObjectStore(gomock.Any()).Return(store, nil).Times(2)
	store.EXPECT().Put(gomock.Any(), "t-uuid-1", gomock.Any(), gomock.Any()).Return("store-1", nil)
	store.EXPECT().Put(gomock.Any(), "t-uuid-2", gomock.Any(), gomock.Any()).Return("store-2", nil)

	in := internal.ImportOperationsArgs{
		{
			ID:    "op-1",
			Tasks: []internal.ImportTaskArg{{ID: "t1", UUID: "t-uuid-1", Output: map[string]any{"a": 1}}, {ID: "t2", UUID: "t-uuid-2", Output: map[string]any{"b": 2}}},
		},
	}

	sentinel := stderrs.New("state-import-fail")
	// Expect state call to fail.
	s.st.EXPECT().InsertMigratingOperations(gomock.Any(), gomock.Any()).Return(sentinel)
	// Expect rollbacks to remove the previously stored entries using the task UUIDs.
	store.EXPECT().Remove(gomock.Any(), "t-uuid-1").Return(nil)
	store.EXPECT().Remove(gomock.Any(), "t-uuid-2").Return(nil)

	err := s.service(c).InsertMigratingOperations(c.Context(), in)
	c.Assert(err, tc.ErrorIs, sentinel)
}

// TestDeleteImportedOperationsWithPathsRemovesObjects covers when there are
// store paths returned. All should be removed and no error returned.
func (s *migrationSuite) TestDeleteImportedOperationsWithPathsRemovesObjects(c *tc.C) {
	ctrl := s.setupMocks(c)
	defer ctrl.Finish()

	store := NewMockObjectStore(ctrl)
	s.st.EXPECT().DeleteImportedOperations(gomock.Any()).Return([]string{"p1", "p2"}, nil)
	s.objGetter.EXPECT().GetObjectStore(gomock.Any()).Return(store, nil)
	store.EXPECT().Remove(gomock.Any(), "p1").Return(nil)
	store.EXPECT().Remove(gomock.Any(), "p2").Return(nil)

	err := s.service(c).DeleteImportedOperations(c.Context())
	c.Assert(err, tc.IsNil)
}

// TestDeleteImportedOperationsObjectStoreGetterError ensures that errors from
// object store getter are propagated.
func (s *migrationSuite) TestDeleteImportedOperationsObjectStoreGetterError(c *tc.C) {
	ctrl := s.setupMocks(c)
	defer ctrl.Finish()

	s.st.EXPECT().DeleteImportedOperations(gomock.Any()).Return([]string{"pX"}, nil)
	s.objGetter.EXPECT().GetObjectStore(gomock.Any()).Return(nil, stderrs.New("get-store-fail"))

	err := s.service(c).DeleteImportedOperations(c.Context())
	c.Assert(err, tc.ErrorMatches, ".*getting object store.*")
}

// TestDeleteImportedOperationsRemoveError logs a warning and still returns nil
// when a removal fails.
func (s *migrationSuite) TestDeleteImportedOperationsRemoveError(c *tc.C) {
	ctrl := s.setupMocks(c)
	defer ctrl.Finish()

	store := NewMockObjectStore(ctrl)
	s.st.EXPECT().DeleteImportedOperations(gomock.Any()).Return([]string{"p1", "p2"}, nil)
	s.objGetter.EXPECT().GetObjectStore(gomock.Any()).Return(store, nil)
	store.EXPECT().Remove(gomock.Any(), "p1").Return(nil)
	store.EXPECT().Remove(gomock.Any(), "p2").Return(stderrs.New("remove-fail"))

	err := s.service(c).DeleteImportedOperations(c.Context())
	c.Assert(err, tc.IsNil)
}
